[ 롤모임 운영일지 ] - 19. 스포일러 없는 티저부터 저주 아이템까지 — 시즌패스 구현기
7월에 시즌패스 “함께하는 여름방학”을 만들어 배포했다. 30레벨 단일 트랙(무료 5개 / 유료 25개 보상), 6주 진행, 일일/주간 미션으로 XP를 모아 레벨을 올리는 전형적인 배틀패스 구조다. 출발점은 거창한 게 아니라 “배팅 포인트가 쌓이기만 하고 쓸 데가 없다”는 경제 문제였다 — 포인트 소진처를 만들면서 리텐션도 노려보자는 것.
14편에서 “이게 리텐션에 실제로 도움이 됐는지 아직 확정하지 않기로 했다”는 지표 이야기는 이미 했으니, 이번 편은 반대쪽 — 만드는 과정에서 했던 기획 판단들을 기록한다. 돌아보니 코드보다 “무엇을 보여주고 무엇을 가릴까”, “꽝을 어떻게 처리할까” 같은 결정에 시간을 더 쓴 작업이었다.
1. 티저 — 무엇을 가리고 무엇을 보여줄 것인가
오픈일(7/15)보다 8일 먼저, 커밍순 티저부터 배포했다. 이때 플러그인 시스템(2편에서 다룬 그 구조)의 이중 스위치가 유용했다 — 시즌패스 플러그인 전체를 hidden + active=false로 배포하면 코드는 프로덕션에 나가 있어도 어느 모임에서도 기능이 안 보인다. 그 상태에서 게이팅 없는 프리뷰 API 하나만 열어 홈 화면에 티저를 띄웠다.
그런데 티저를 만들고 40분 뒤에 이런 커밋을 남겼다.
“커밍순 화면 보상 스포일러 제거, 재미/안내 위주로 개편 — 레벨별 구체적 보상 목록 대신 ‘미션→XP→레벨업→보상’ 구조 설명과 오픈일 안내로 대체. 보상 내용은 오픈 전까지 비공개.”
처음 만든 티저가 보상을 전부 나열하고 있었는데, 이러면 오픈 날 아무 서프라이즈가 없겠다 싶어서 싹 가렸다. 그런데 같은 날 오후에 다시 뒤집었다 — 레벨별 보상 목록을 도로 넣었다. 다만 코스메틱 보상만 ❓로 가린 채로.
전부 보여주면 김이 새고, 전부 가리면 기대할 거리 자체가 없다. 하루 안에 양 극단을 다 가본 끝에 “뼈대(30레벨, 포인트 보상)는 공개하고, 새로 만든 코스메틱만 가린다”는 중간 지점에 도착했다. 티저는 정보를 주는 화면이 아니라 기대감의 수위를 조절하는 화면이라는 걸, 만들면서 배웠다.
운영 쪽 안내도 같은 맥락에서 나눠 설계했다: 일반 유저에겐 티저만, 전체 모임 운영진에겐 오픈 전날 “내일 오픈” 푸시 1회(발송 시각을 기록해 멱등 처리), 그리고 플랫폼 어드민인 나는 requirePlugin을 우회해 오픈 전에도 실제 플로우를 테스트할 수 있게 했다.
2. 박스 오픈 연출 — “감성”이 기능만큼 커밋을 먹었다
레벨 보상을 처음 배치해보니 30레벨 중 19개가 그냥 포인트(코인)였다. 시즌패스를 깼는데 보상이 대부분 “포인트 +N”이면 아무 재미가 없다. 그래서 코인 필러 19개를 미스터리박스 10개 + 코인 9개로 재배치했다(박스에 기대값이 들어가는 만큼 코인 총액은 4,250P에서 2,450P로 줄였다). 박스 안에는 부스터·코스메틱 체험권·저주가 랜덤으로 들어 있다.
그러자 이번엔 “박스를 까는 순간”이 문제가 됐다. 처음 구현은 버튼 누르면 결과 텍스트가 뜨는 게 전부였는데, 이건 미스터리박스가 아니라 그냥 확률형 영수증이다. 같은 날 저녁에 연출 커밋이 연달아 쌓였다: 오픈 애니메이션과 결과 팝업을 붙이고, 마지막엔 결과 화면 자체를 포켓몬/유희왕 풍 트레이딩 카드로 바꿨다 — 레어도별 테두리 색과 글로우(부스터=골드, 저주=퍼플, 체험권=스카이), 홀로그램 반짝임, 회전+스케일로 튀어나오는 등장 애니메이션. 단일 오픈은 큰 카드 1장, 일괄 오픈은 카드 그리드.
기능만 보면 “인벤토리에서 아이템 사용” 한 줄인데, 커밋 수로는 연출 작업이 기능 구현과 비슷하게 들어갔다. 아깝다는 생각은 안 들었다 — 보상의 체감 가치는 내용물 반, 까는 순간의 경험 반이라고 지금도 생각한다.
3. 저주 — 꽝을 콘텐츠로 만들기
확률형 박스에는 꽝이 필요하다. 꽝이 없으면 박스가 아니라 적금이니까. 그런데 “꽝입니다” 카드를 주는 대신, 꽝 자리를 저주 5종으로 채웠다: 닉네임 뒤집기, 닉네임 흔들림, 저주받은 칭호, 경매 채팅 이모지 도배, 느낌표 폭발. 뽑는 즉시 24시간 적용되고, 같은 저주를 또 뽑으면 시간이 스택된다(24h × 장수). 중요한 건 이게 남들 눈에도 보인다는 것 — 닉네임이 뒤집힌 채 경매 채팅에 느낌표를 쏟아내는 유저는 그 자체로 모임의 구경거리가 된다. 꽝을 손실이 아니라 밈으로 바꾸는 설계다.
저주의 반대편에는 체험권 4종을 뒀다. 유료 코스메틱(네임이펙트, 칭호 테두리, 프로필 카드 테두리, 말풍선 스킨)을 24시간 임시 장착해주는 아이템인데, 렌더링은 정식 장착과 같은 자리에서 “장착됨 OR 활성 효과” 판정으로 처리했다. 저주가 밈이라면 체험권은 광고다 — 박스에서 나온 24시간이 끝나면 그 꾸미기를 사고 싶어지길 노린 배치.
구현에서 하나 신경 쓴 건, 코스메틱·저주·체험권이라는 세 종류의 “지금 이 유저에게 적용 중인 효과”를 API 응답 하나(effects)로 합쳐 내려보낸 것이다. 닉네임 컴포넌트는 목록 화면에 수십 개씩 렌더링되는데, 효과 종류마다 요청을 따로 보내기 시작하면 4편에서 잡았던 것과 같은 부류의 문제를 스스로 만드는 셈이 된다.
4. 미션 — enum이 아니라 정책이다
미션은 일간/주간 템플릿 풀에서 뽑아 배정한다. 처음엔 풀이 각 4개뿐이고 슬롯도 모임 전체가 공유했는데, 이러면 매일 모두가 같은 미션을 받는다. 시즌 중반에 풀을 각 30개로 늘리고 슬롯을 유저별 배정으로 바꿔서, 같은 모임이라도 사람마다 다른 조합이 뜨게 했다. 방명록·구인구직·연승배팅·챔피언폭·동료평가처럼 사이트 전반의 기능을 건드리는 신호들을 미션으로 넣었다 — 미션이 곧 기능 투어가 되도록.
이 시스템에서 가장 크게 배운 건 미션 하나 때문이었다. BET_STORE_BUY — “배팅상점에서 구매하기” 미션. 처음 구현은 betStorePurchase 테이블 카운트 하나였다. 그런데 오픈 후 “확성기를 샀는데 미션이 안 깨져요”라는 신고가 왔다. 확성기 카드는 배팅상점 페이지 안에 있지만, 코드에서는 별도 라우트에 별도 테이블이었던 것. 고쳤더니 며칠 뒤 말풍선 스킨 구매도 같은 이유로 안 잡힌다는 신고가 왔고, 그걸 고치면서 보니 칭호·닉네임색·말풍선색 구매는 아예 구매 기록 테이블 자체가 없었다 — User 필드만 덮어쓰고 끝이라 셀 방법이 없어서, CustomizationPurchase 로그 테이블을 새로 만들어야 했다. 같은 날 밤 11분 사이에 커밋 세 개가 쌓였다.
// packages/api/src/lib/season-pass.ts
case "BET_STORE_BUY": {
// 배팅상점 페이지의 BetCoin 구매 액션(아이템/확성기/말풍선 스킨/칭호/닉네임색/말풍선색)을 합산.
// 복권(lottery)만 확률형이라 제외.
const gid = getGroupId() ?? 1;
const [purchases, megaphones, bubbleSkins, customizations] = await Promise.all([
prisma.betStorePurchase.count({ where: { groupId: gid, userId, createdAt: { gte: start, lt: end } } }),
prisma.megaphone.count({ where: { groupId: gid, userId, createdAt: { gte: start, lt: end } } }),
prisma.bubbleSkinPurchase.count({ where: { groupId: gid, userId, createdAt: { gte: start, lt: end } } }),
prisma.customizationPurchase.count({ where: { groupId: gid, userId, createdAt: { gte: start, lt: end } } }),
]);
return purchases + megaphones + bubbleSkins + customizations;
}
유저에게 “배팅상점”은 페이지 하나다. 하지만 코드에는 테이블 네 개에 라우트도 제각각이었다. 미션 정의는 enum 값이 아니라 “유저가 그 단어를 어떻게 이해하는가”에 대한 정책이고, 상점에 상품이 추가될 때마다 미션 정의도 따라와야 한다. 복권만은 의도적으로 뺐다 — 확률형 구매까지 미션으로 세면 미션이 도박 유도가 되니까.
반대 방향의 교훈도 있었다. 템플릿 풀에 넣어뒀던 POINT_BET_JOIN(포인트배팅 참여) 미션은, 포인트배팅 기능 자체가 서비스 전체에서 단 한 번도 쓰인 적이 없어서 유저가 절대 깰 수 없는 죽은 미션이었다. 14편에서 “포인트베팅 0건, 정리 1순위”라고 분석했던 바로 그 기능이다. 죽은 기능을 미션 풀에 넣으면 미션도 같이 죽는다 — 해당 템플릿들은 실제로 쓰이는 연승/내전 배팅 미션으로 전환했다.
정리
- 티저는 정보 공개가 아니라 기대감 수위 조절이다. 전부 보여주기 → 전부 가리기 → “뼈대는 공개, 코스메틱만 ❓“까지 하루 만에 오갔고, 마지막 절충이 정답에 가장 가까웠다.
- 플러그인 이중 스위치 덕에 “코드는 배포됐지만 기능은 잠긴” 상태로 티저 기간을 운영할 수 있었다. 오픈 날에는 스위치만 켜면 됐다.
- 보상의 체감 가치는 내용물 반, 연출 반이다. 트레이딩 카드 연출에 기능 구현만큼의 커밋을 썼고, 후회하지 않는다.
- 꽝은 저주로, 저주는 밈으로. 손실을 구경거리로 바꾸면 확률형 보상의 하방이 콘텐츠가 된다.
- 미션 정의는 enum이 아니라 정책이다. “배팅상점 구매”라는 한 단어가 테이블 네 개였고, 그중 하나는 로그 테이블을 새로 만들어야 셀 수 있었다. 그리고 죽은 기능을 미션에 넣으면 미션도 죽는다.
시즌패스를 만들며 겪은 사고들 — 코스메틱 조회 훅이 무한 요청 루프를 돈 사건, 신규 테이블에 soft-delete 필터가 자동 주입돼 전 라우트가 500을 낸 사건 — 은 각각 별도 편에서 다룬다.
댓글
아직 댓글이 없어요. 첫 댓글을 남겨보세요.